Skip to content

perf: hot-path store reads and translation processing - #18

Draft
jarda-svoboda wants to merge 6 commits into
fix/locale-key-safetyfrom
perf/hot-paths
Draft

perf: hot-path store reads and translation processing#18
jarda-svoboda wants to merge 6 commits into
fix/locale-key-safetyfrom
perf/hot-paths

Conversation

@jarda-svoboda

@jarda-svoboda jarda-svoboda commented Jul 2, 2026

Copy link
Copy Markdown
Member

Performance work on the read and load hot paths. 6 commits, each independently green.

> Stacked on #19 — this PR targets fix/locale-key-safety, so its diff shows only the performance changes. Merge #19 first; GitHub will retarget this to master automatically.

Commits

# Commit What
1 perf(utils): make toDotNotation linear in key count The computed-key spread per key copied the accumulator on every step — quadratic in the number of keys, and the dominant cost of every translation load. Mutates a function-local null-prototype accumulator instead, finished with a single spread that restores a normal prototype while keeping a literal __proto__ key an own property.
2 perf(utils): cache successful locale sanitizations Intl.Collator.supportedLocalesOf is expensive and the same locales repeat on every store recompute. Bounded LRU (1000 entries, reinserted on hit, so a flood of visitor-supplied locales evicts itself rather than the app's own). Only string inputs are cached; failures are never cached, so a locale Intl cannot resolve yet can recover and its warning stays tied to the call rather than to whichever logger and level happened to be installed first.
3 perf(index): sanitize once per getLocale lookup, fallback lazily Sanitization ran once per candidate locale inside the .find() predicates. Hoisted, fallback kept short-circuited, and both skipped when there is nothing to match against — sanitizing there only emitted a warning for a lookup that cannot succeed.
4 perf(index): keep read-path derived chains warm get() on an inactive svelte derived re-initializes and tears down the whole chain, so every t.get() re-ran the translation callback and scaled with table size. Holds no-op subscriptions to t, l, initialized. locales is deliberately left cold — see below.
5 feat: add destroy() to stop loading and fence in-flight results The warm subscriptions (and the pre-existing ones) had no teardown. destroy() detaches them, clears pending promises, resets the loading flag, and refuses every entry point that would load — setLocale, setRoute, locale.set/locale.update, loadTranslations, loadConfig — with one warning naming the call the consumer actually made. Results arriving from an in-flight load are dropped by the loader's own fence. Reads and consumer-driven addTranslations() keep working: a destroyed instance stops loading, not storing.
6 docs: allow function-local mutable accumulators on measured hot paths AGENTS.md §10 mandated the spread-accumulation style commit 1 deviates from.

Why locales stays cold

It is the only read-path chain whose callback touches consumer-supplied config objects (loaders.map(({ locale }) => locale)). Warming it moves that read into a store flush, and svelte's subscriber queue does not unwind on a throw — a single malformed loader would then wedge store propagation process-wide instead of failing the one lookup that read it. Verified: warming it turns one bad loader into five unrelated test failures. There is a regression test pinning it.

Measured (Node 22, svelte 5.55, built bundle)

Scenario Before After
t.get() × 10k, 5000-key table 5255 ms ~21 ms
addTranslations (8000 keys) ~240 ms ~9 ms
sanitizeLocales × 10k (4 locales) 24.7 ms 0.7 ms

Testing

  • npm test — 70 tests; every commit verified green independently (59 / 61 / 61 / 63 / 70 / 70)
  • npm run test:dist — 3 tests against the built ESM and CJS bundles
  • npm run build, npm run typecheck and npm run lint clean
  • ✅ Regression tests verified red→green: bare + nested __proto__ keys, warm-chain reference equality, locales left cold, in-flight load dropped after destroy() (fails when the loader's destroyed fence is removed), locale.set after destroy() reported, loadTranslations after destroy() reported once, loadConfig after destroy(), locale-cache success/failure contract

Notes

  • Review dropped two earlier commits: a hasKeys helper (benchmarked no faster than the Object.keys().length it replaced) and a serialize/loader-key micro-optimization (negligible at realistic sizes, and it duplicated the accumulator pattern). Their removal also retired the safeTable/sealTable helpers — the null-prototype pattern now has exactly one site.
  • An earlier revision raised the root TypeScript target to es2020, silently changing the published bundle's language level to fix a test-harness artifact. Reverted; the published target stays es2017 and the test-only fix lives in fix: prototype-safe locale reads and loader error handling #19.
  • An earlier revision also deduplicated the non-standard-locale warning to once per locale. Dropped: the dedupe recorded the locale before the logger's level filter ran, so a first occurrence suppressed at level: 'error' silenced the diagnostic for every later instance in the process. The warning is back to once per call.
  • Not done deliberately: null-prototype migration of the public translation tables (breaking shape change) and in-place mutation of addTranslations' per-load merge (consumers rely on immutable table references).
  • AGENTS.md §10 is relaxed by commit 6 to permit the style commit 1 introduces. The previous blanket "no mutation" was stricter than needed, and the three constraints replacing it (function-local, null-prototype, single spread before the result escapes) keep the pollution guarantee intact. The rule now also states which long-lived state may be written in place (loadedKeys), so §10 and §11 no longer contradict each other.

Known follow-ups (non-blocking)

  • The warm-chain pinning test asserts reference equality of get() results, which pins svelte's derived caching rather than the mechanism itself. Deterministic today (verified red→green) but would need a subscription-counting probe to be implementation-independent.
  • Warming lives in the constructor, separate from the store definitions, so adding a new public derived store requires remembering a second step. An extendedDerived() factory owning both would make that structural.
  • isLoading and promises are two representations of "a load is in flight". Deriving the flag from the in-flight set would collapse them and remove the purge subscriber entirely — the source of two bugs fixed in fix: prototype-safe locale reads and loader error handling #19. Deliberately not done here: it is a design change, and it belongs with the store-layer work planned for the next major.
  • dist/index.cjs cannot be loaded on a Node old enough to reject require() of an ESM dependency, because svelte 5 ships its store as ESM only. Pre-existing packaging issue; the bundle spec skips that assertion where it applies rather than papering over it.

@jarda-svoboda
jarda-svoboda requested a review from cam-eo July 2, 2026 10:55
@jarda-svoboda jarda-svoboda added the enhancement New feature or request label Jul 2, 2026
@jarda-svoboda
jarda-svoboda marked this pull request as draft July 10, 2026 01:00
@jarda-svoboda
jarda-svoboda force-pushed the perf/hot-paths branch 2 times, most recently from 3aede8f to e146dac Compare July 11, 2026 14:58
@jarda-svoboda
jarda-svoboda force-pushed the perf/hot-paths branch 2 times, most recently from 8a1417e to fa628ba Compare July 25, 2026 12:37
@jarda-svoboda
jarda-svoboda changed the base branch from master to fix/locale-key-safety July 25, 2026 12:38
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from ad5f220 to a024e37 Compare July 25, 2026 15:04
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from a024e37 to 1844911 Compare July 25, 2026 15:53
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 1844911 to 693df58 Compare July 25, 2026 23:47
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 693df58 to 7935d68 Compare July 26, 2026 11:40
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 7935d68 to 68d31ba Compare July 26, 2026 16:11
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 68d31ba to b47f600 Compare July 26, 2026 17:28
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from b47f600 to 9c7bd64 Compare July 26, 2026 17:32
@jarda-svoboda
jarda-svoboda force-pushed the perf/hot-paths branch 2 times, most recently from ea55550 to 31f88f0 Compare July 26, 2026 17:41
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 680c19a to 83b4d74 Compare July 26, 2026 22:30
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 83b4d74 to 41e2fbf Compare July 26, 2026 23:38
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 41e2fbf to f236673 Compare July 27, 2026 00:14
claude added 4 commits July 27, 2026 01:22
The computed-key spread per key copied the accumulator on every step, which is
quadratic in the number of keys and the dominant cost of every translation
load. The accumulator is mutated instead — function-local, null-prototype so a
literal '__proto__' key stays an own property rather than reaching the
prototype setter, and finished with a single spread that restores a normal
object with DefineProperty semantics.
`Intl.Collator.supportedLocalesOf` is expensive and the same locales repeat on
every store recompute. Successful lookups are cached LRU (bounded, reinserted
on hit, so a flood of visitor-supplied locales evicts itself rather than the
app's own). Only string inputs are cacheable — for any other input the string
form is not a faithful key — and failures are never cached, so a locale Intl
cannot resolve yet can recover and its warning stays tied to the call.
sanitizeLocales ran inside the .find() callbacks — once per candidate
locale per lookup. Hoist the requested locale's sanitization, keep the
fallback's inside the miss branch so it stays short-circuited, and skip
both when there are no locales to match against: sanitizing there only
emitted a non-standard warning for a lookup that cannot succeed.
get(store) on an inactive svelte derived re-initializes and tears down
the entire dependency chain on every call, so each t.get()/l.get()
re-ran the translation store callback — scaling every read with the
translation table size (5255 ms for 10k reads of a 5000-key table;
~20 ms with warm chains). Hold no-op subscriptions to t, l and
initialized so the chains stay active.

locales stays cold on purpose: its callback re-sanitizes every loader
locale, so warming it would turn the non-standard-locale warning from
once per read into once per store write. Also default the destructured
config in loading.toPromise, which is now reachable before any config
is set.
@jarda-svoboda
jarda-svoboda force-pushed the fix/locale-key-safety branch from 0ee6479 to de73253 Compare July 27, 2026 01:22
claude added 2 commits July 27, 2026 01:22
The constructor holds subscriptions for the instance's whole life (the
loader trigger, promise purging, the warm derived chains) with no way to
detach them, and a load started before teardown still wrote its result
to the stores afterwards.

destroy() detaches those subscriptions, clears pending promises, resets
the loading flag, and marks the instance inactive: every entry point
that would load or mutate now bails through one shared gate that warns,
and results arriving from an in-flight load are dropped. Reads keep
working. Documented in docs/README.md, including that it is not needed
for garbage collection — the subscription graph is instance-internal.
The rule mandated the computed-key-spread accumulation style
unconditionally, which toDotNotation now deviates from for performance.
State what the code actually does: immutable style for shared state,
mutation only into function-local null-prototype accumulators finished
with a single spread, never on store state.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants